Skip to content

fix(mcp): make stateless_http configurable and session-capable by default - #230

Closed
street1983nk wants to merge 1 commit into
nextcloud:mainfrom
street1983nk:fix/stateless-http-session-compat
Closed

fix(mcp): make stateless_http configurable and session-capable by default#230
street1983nk wants to merge 1 commit into
nextcloud:mainfrom
street1983nk:fix/stateless-http-session-compat

Conversation

@street1983nk

Copy link
Copy Markdown

Problem

Clients built on the MCP Python SDK >= 1.28 (Claude Code, Hermes Agent, Cursor and other
Streamable HTTP clients) cannot stay connected to the context_agent MCP server. The
initialize call succeeds and the tools are discovered, but the next request fails:

McpError: Session terminated
MCP server 'Nextcloud' failed initial connection after 3 attempts, parking

Cause

ex_app/lib/main.py mounts the MCP app with a stateless transport:

http_mcp_app = mcp.http_app("/", transport="http", stateless_http=True)

With fastmcp 2.14.7 that flag makes the session manager build a throwaway transport per
request and call terminate() once the request is answered
(StreamableHTTPSessionManager._handle_stateless_request). Every POST becomes an
independent transaction, so a client that keeps the ClientSession it created during
initialize is talking to a session that no longer exists.

The same setting also costs both server to client channels on that leg: server initiated
requests raise NoBackChannelError and notifications are dropped silently.

Fix

One functional change, no other scope:

# Session-capable by default: SDK >= 1.28 clients keep the session after initialize
# and fail with "Session terminated" when the transport is stateless. See #227.
_stateless_http = os.getenv("MCP_STATELESS_HTTP", "0").lower() in ("1", "true", "yes")
http_mcp_app = mcp.http_app("/", transport="http", stateless_http=_stateless_http)

os is already imported in that module, so the diff stays at one file and four lines.
The call keeps the existing fastmcp 2.14.7 http_app signature, so this is independent of
the pending fastmcp 3.x bump in #177.

Backwards compatibility

The default flips from stateless to session-capable, which is what fixes the bug. Nobody
loses the old behaviour: a deployment that deliberately wants a stateless transport, for
example to spread the legacy leg over several workers without sticky routing, sets
MCP_STATELESS_HTTP=1 and gets exactly what it has today. Session-capable transports keep
their session state in process, so a multi worker deployment that does not set the variable
needs sticky routing.

Reproduction

The failure only shows with a client on the 1.x SDK line, because a mcp >= 2 client using
the 2026-07-28 protocol era is sessionless by construction and never reaches the code path
that reads stateless_http. That asymmetry is why the server looks healthy in some setups.

  1. Run context_agent (2.8.0 or current main) on a Nextcloud instance and note the
    Streamable HTTP endpoint URL your MCP client is configured with.

  2. Save this as legacy_client_check.py:

    import asyncio
    import sys
    
    from mcp import ClientSession
    from mcp.client.streamable_http import streamablehttp_client
    
    
    async def check(url: str) -> int:
        headers = {"Authorization": "<the same auth header your MCP client sends>"}
        async with (
            streamablehttp_client(url, headers=headers) as (read, write, _session_id),
            ClientSession(read, write) as session,
        ):
            await session.initialize()
            result = await session.list_tools()
        print(f"tools/list returned {len(result.tools)} tools")
        return 0
    
    
    sys.exit(asyncio.run(check(sys.argv[1])))
  3. Run it against the endpoint with the legacy SDK pinned into an isolated environment:

    uv run --isolated --no-project --with "mcp>=1.29,<2" \
        python legacy_client_check.py https://<nextcloud>/<mcp-endpoint>

Before the fix: initialize returns, then tools/list raises McpError: Session terminated
and the script exits non zero.

After the fix (or with MCP_STATELESS_HTTP unset on a patched deployment): the script prints
the number of tools and exits 0. Setting MCP_STATELESS_HTTP=1 reproduces the old failure,
which is a convenient way to confirm that the switch really is the cause.

Where the automated regression test lives

Automating this inside this repository would need a second client environment on the 1.x SDK
line talking to a running ExApp container, on top of an already heavy server version matrix
(master, stable33, stable32, stable31 plus the llm2 app). That would add a lot of CI weight
and flakiness for one flag, so the check is automated in our project instead, and it is the
source of the reproduction above:

  • Repository: https://github.com/street1983nk/nextcloud-mcp-connector
  • tests/compat/legacy_client_check.py performs initialize plus tools/list under
    mcp>=1.29,<2 in its own environment and exits 1 on "Session terminated".
  • tests/compat/test_client_matrix.py runs that legacy client and a mcp>=2,<3 client
    against the same endpoint, so a stateless transport regression fails the build.

Happy to switch this to a plain stateless_http=False without the environment variable if
you prefer the smaller surface.

Fixes #227

…ault

Clients built on the MCP SDK >= 1.28 keep the session that initialize
creates. With stateless_http=True the fastmcp transport throws that
session away per request, so the next call fails with "Session
terminated" and both server-to-client channels are gone.

Default to a session-capable transport, which is what those clients
expect, and keep the previous behaviour available for deployments that
want it by setting MCP_STATELESS_HTTP=1.

Fixes nextcloud#227

Signed-off-by: street1983nk <k.cherif@outlook.de>
@marcelklehr

Copy link
Copy Markdown
Member

Hi!

Thank you for taking the time to submit this PR.

Session terminated is raised in one place only: mcp/client/streamable_http.py:350-356, on an HTTP 404 to a POST, which a stateless server structurally can't produce, since _handle_stateless_request builds a fresh transport unconditionally. The reported 404 more likely comes from UserAuthMiddleware.on_message raising on a missing authorization header (ex_app/lib/mcp_server.py:32-41) or the AppAPI proxy route.

Also, there are several flaws with this supposed fix:

Unbounded session/task leak (high) — main.py:44

25 abandoned initialize POSTs against a stateful server left 25 entries in StreamableHTTPSessionManager._server_instances and 25 live tasks, none reclaimed. fastmcp 2.13.0.2 never passes session_idle_timeout, so the SDK's idle reaper is inert and a client-sent DELETE is the only cleanup path. The ExApp is one long-lived uvicorn process, so sessions accumulate for the container's lifetime. Stateless mode called terminate() after every request — this failure mode is new.

Each client pins a Nextcloud PHP worker (high) — main.py:44

Request logs for a full session: stateless → POST ×5; stateful → POST, POST, GET, POST, POST, POST, DELETE. That GET is the standalone SSE stream; handle_get_stream short-circuits only when session_id is None. ExAppProxyController::ExAppGet proxies with 'stream' => true and TIMEOUT => 0, so one PHP-FPM worker is held for each MCP client's connection lifetime. A few persistent clients (Claude Code, Cursor) can exhaust the pool and take down the instance, not just the ExApp.

The opt-out is unreachable (medium) — main.py:43

MCP_STATELESS_HTTP isn't declared in appinfo/info.xml under . AppAPI only plumbs declared variables (ExAppService.php:301-322 — occ app_api:app:register --env applies a value only if (array_key_exists($key, $envVars))), so it's silently dropped. The PR body's "nobody loses the old behaviour" doesn't hold without a matching info.xml change.

@street1983nk

Copy link
Copy Markdown
Author

Hi Marcel,

thank you for the thorough review. You are right, and I want to be upfront about it: I re-verified every point against the pinned versions (fastmcp 2.14.7, mcp 1.29.0) including live reproductions, and my root-cause analysis in this PR was wrong.

What I found when re-testing:

  1. An mcp 1.29.0 client runs initialize, tools/list and tools/call flawlessly against a stateless fastmcp 2.14.7 server. So the stateless transport is not the cause, exactly as you said.
  2. "Session terminated" is only raised on an HTTP 404 to a follow-up POST (streamable_http.py:350-356). A simulated proxy that lets initialize through and answers the next POST with 404 reproduces the reported symptom precisely.
  3. One small correction to your note: an exception in UserAuthMiddleware.on_message returns HTTP 200 with a JSON-RPC error (-32602), not 404 (verified with curl). That narrows the 404 source down to the AppAPI proxy layer: ExAppProxyController::prepareProxy returns NotFoundResponse when the app or route lookup or the access-level check fails, and it logs the reason ("Returning status 404 for ..."). That log line is where the real diagnosis should start.
  4. Your three flaws are all real in the pinned versions: fastmcp never passes session_idle_timeout (the SDK's idle reaper is unreachable), the stateful GET stream plus the proxy's stream=true/TIMEOUT=0 would pin one PHP-FPM worker per client, and MCP_STATELESS_HTTP would have been silently dropped by AppAPI since it is not declared in info.xml. My "nobody loses the old behaviour" claim did not hold.

Given all that, flipping the default to stateful would have made things worse, not better. I am closing this PR. If it helps, I can follow up in #227 with the prepareProxy 404 reason from a live setup so the actual proxy-layer cause gets pinned down.

Thanks again for taking the time to lay this out in such detail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP Server incompatible with MCP SDK ≥1.28 clients: stateless_http=True causes immediate session termination

2 participants